In [1]:
import sklearn.metrics

from sklearn.datasets import fetch_20newsgroups

Explaing Text Classifiers with the altered Lime explanasion

The classical Lime approach

Fetching a suitable Dataset

Since there exists a nice example for explaining multi-class text classifiers with lime , I will use this as basis for my example.

This example is based on the famous 20 newsgroup dataset.

In [2]:
dataset_complete = fetch_20newsgroups()

newsgroups_train = fetch_20newsgroups(subset='train')
newsgroups_test = fetch_20newsgroups(subset='test')

# making class names shorter
class_names = [x.split('.')[-1] if 'misc' not in x else '.'.join(x.split('.')[-2:]) for x in newsgroups_train.target_names]
class_names[3] = 'pc.hardware'
class_names[4] = 'mac.hardware'

Training a testable Classifier

I use the same classifier as the Lime example. Therefore, a tfidf vectorizer is used to train a Multinomial Naive Bayes for classification.

In [3]:
vectorizer = sklearn.feature_extraction.text.TfidfVectorizer(lowercase=False)
train_vectors = vectorizer.fit_transform(newsgroups_train.data)
test_vectors = vectorizer.transform(newsgroups_test.data)



from sklearn.naive_bayes import MultinomialNB
nb = MultinomialNB(alpha=.01)
nb.fit(train_vectors, newsgroups_train.target)


pred = nb.predict(test_vectors)
sklearn.metrics.f1_score(newsgroups_test.target, pred, average='weighted')
Out[3]:
0.8350184193998174

As shown, the classifier provides a suitabe F-Score, However, thei classifier is to be susceptible for overfitting on this dataset (https://scikit-learn.org/stable/datasets/#filtering-text-for-more-realistic-training).

Explaining text predictions using the classical lime approach

To do so, an explainer instance has to be created and a document within our test data-set has to be chosen.

In [4]:
from lime import lime_text
from sklearn.pipeline import make_pipeline
c = make_pipeline(vectorizer, nb)



from lime.lime_text import LimeTextExplainer
explainer = LimeTextExplainer(class_names=class_names)

# choose the instance
idx = 1340
# explain the instance classification
exp = explainer.explain_instance(newsgroups_test.data[idx], c.predict_proba, num_features=6, labels=[0, 17])
print('Document id: %d' % idx)
print('Predicted class =', class_names[nb.predict(test_vectors[idx]).reshape(1,-1)[0,0]])
print('True class: %s' % class_names[newsgroups_test.target[idx]])
Document id: 1340
Predicted class = atheism
True class: atheism

Now, the explanation can be looked at in detail:

In [5]:
print ('Explanation for class %s' % class_names[0])
print ('\n'.join(map(str, exp.as_list(label=0))))
print ()
print ('Explanation for class %s' % class_names[17])
print ('\n'.join(map(str, exp.as_list(label=17))))
Explanation for class atheism
('Caused', 0.26609399925692734)
('Rice', 0.1378039956368781)
('Genocide', 0.12998739891022845)
('certainty', -0.09551459603767813)
('scri', -0.0886341515265186)
('owlnet', -0.08754077100355254)

Explanation for class mideast
('Luther', -0.054440937044945056)
('fsu', -0.054313452021313706)
('Theism', -0.05289976788292845)
('Caused', -0.0355827069493329)
('jews', 0.03521101931524384)
('PBS', 0.03174801945120821)

And we can visualize the explanation to be more user friendly. Here we only visualize the explanasion for the top class.

In [6]:
exp.show_in_notebook(text=False, labels=exp.available_labels()[:1])

The new Lime approach using topics instead of words

To demonstrate the new version of the Lime explainer based on topics, the same classifier and dataset will be used.

Generating a topic map based on the same dataset

A well known approach to topic modelling is the Latent Dirichlet Allocation (LDA). To generate a good topic model the text-corpus on which the LDA should be generated should be preprocessed. Since, LDA is a statistical approach, that looks at the appreance of words within documents, the words should be transformed into their lemmatized form.

The new Lime text explainer, includes a simple class that can be used for preprocessing a text corpus.

In [7]:
from src.lime_text_topics import PreProcessor

# Preprocess document within dataset
# includes removing of unnecessary characters and lemmatization

data_processor = PreProcessor()

To create topics using LDA, the implementation within gensim is used. Here we create 20 topics using the complete 20 newsgroup dataset.

Since creating a LDA takes some time, a previously trained LDA loaded

In [8]:
import gensim
from gensim.test.utils import datapath
from gensim.models import LdaModel
from gensim.corpora import Dictionary

# Save model to disk.
temp_file = datapath("secretLocation/src/examples/models/20_newsgroup/lda_20newsgroup_20topics.model")
# Load a potentially pretrained model from disk.
lda_model = LdaModel.load(temp_file)

topics = []
for x in range(lda_model.num_topics):
    topics.append('topic #'+str(x))

# and load the dictionary for the LDA_model
id2word = Dictionary.load_from_text('secretLocation/src/examples/models/20_newsgroup/20_newsgroup_dict')

We can look at the topics created by th LDA

In [9]:
print(f'Number of topics: {lda_model.num_topics}')
# print top words for one topic
lda_model.print_topics(1)
Number of topics: 20
Out[9]:
[(16,
  '0.023*"think" + 0.019*"say" + 0.018*"know" + 0.017*"article" + 0.017*"people" + 0.015*"make" + 0.012*"see" + 0.012*"believe" + 0.010*"many" + 0.010*"way"')]

Unfortunatly, LDA considers all words to be present in all topics. Moreover, the model was build on a lemmatized version of the dataset. Therefore, a function that maps words to words within the LDA mus be created that can be passed on to the new Lime approach. It will map a word to its lemmatized form and reeturn a list of all topics in which the word is present with a higher probability than a defined minimum probability

In [10]:
minimum_probability=0.002
 
def word_to_topics(word):
    """
    Maps a word on its corresponding topics
    :param word: the word that is searched for
    :return: list of topics
    """

    word_lem_list = data_processor.lemmatize(word)
    if len(word_lem_list)>0:
        word_lem = word_lem_list[0]
        if  word_lem in id2word.token2id:
            word_id = id2word.token2id[word_lem]
            return [x[0] for x in lda_model.get_term_topics(word_id,minimum_probability=minimum_probability)]
        else:
            return []
    else:
        return []

Now we can use this function to explain the classifier with the newly generated topics.

In [11]:
from src.lime_text_topics import LimeTextByTopicsExplainer

explainer_mod = LimeTextByTopicsExplainer(class_names=class_names, word_to_topics=word_to_topics, topics=topics)
exp_mod = explainer_mod.explain_instance(newsgroups_test.data[idx], c.predict_proba, num_features=6, top_labels=2)
print('Document id: %d' % idx)
print('Predicted class =', class_names[nb.predict(test_vectors[idx]).reshape(1,-1)[0,0]])
print('True class: %s\n' % class_names[newsgroups_test.target[idx]])

for x in exp_mod.available_labels():
    print ('Explanation for class %s' % class_names[x])
    print ('\n'.join(map(str, exp_mod.as_list(label=x))))
    print ()
    
Document id: 1340
Predicted class = atheism
True class: atheism

Explanation for class atheism
("topic #0 = ['Genocide', 'history', 'war']", 0.09298092527149696)
("topic #4 = ['article', 'use', 'treatment', 'College']", -0.044610186522369484)
("topic #1 = ['Genocide', 'writes', 'saw', 'day', 'history', 'religious', 'Later', 'centuries', 'rises']", 0.02731036725259473)
("topic #7 = ['saw', 'day', 'Later', 'remember']", -0.022880989527405846)
("topic #11 = ['Lines', 'writes', 'saw', 'use', 'writings', 'reading', 'sure']", -0.013587721424378925)
("topic #13 = ['article', 'saw', 'show', 'day', 'remember', 'shot', 'course', 'much', 'sure']", -0.011677866472982629)

Explanation for class christian
("topic #0 = ['Genocide', 'history', 'war']", -0.07420628568869946)
("topic #4 = ['article', 'use', 'treatment', 'College']", 0.04179680878936093)
("topic #1 = ['Genocide', 'writes', 'saw', 'day', 'history', 'religious', 'Later', 'centuries', 'rises']", -0.04082556640194533)
("topic #16 = ['Evidence', 'article', 'writes', 'saw', 'show', 'use', 'reading', 'course', 'much', 'sure', 'truth', 'truths', 'doubt']", 0.017179202791432802)
("topic #7 = ['saw', 'day', 'Later', 'remember']", 0.016222379174578614)
("topic #11 = ['Lines', 'writes', 'saw', 'use', 'writings', 'reading', 'sure']", 0.015686362095766265)

and again we can visualize the outcome for the user:

In [12]:
exp_mod.show_in_notebook(text=False, labels=exp_mod.available_labels())

We can also show create a fake topic that includes all words that are not included in the LDA.

In [13]:
explainer_mod_all_words = LimeTextByTopicsExplainer(class_names=class_names, word_to_topics=word_to_topics, topics=topics, consider_all_words=True)
exp_mod_all_words = explainer_mod_all_words.explain_instance(newsgroups_test.data[idx], c.predict_proba, num_features=6, top_labels=2)
print('Document id: %d' % idx)
print('Predicted class =', class_names[nb.predict(test_vectors[idx]).reshape(1,-1)[0,0]])
print('True class: %s\n' % class_names[newsgroups_test.target[idx]])

for x in exp_mod_all_words.available_labels():
    print ('Explanation for class %s' % class_names[x])
    print ('\n'.join(map(str, exp_mod_all_words.as_list(label=x))))
    print ()

exp_mod_all_words.show_in_notebook(text=False, labels=exp_mod_all_words.available_labels())
Document id: 1340
Predicted class = atheism
True class: atheism

Explanation for class atheism
("Unknown = ['From', 'conor', 'owlnet', 'rice', 'edu', 'Conor', 'Frederick', 'Prischmann', 'Subject', 'Re', 'is', 'Caused', 'by', 'Theism', 'Organization', 'Rice', 'University', '23', 'In', 'C60A0s', 'DvI', 'mailer', 'cc', 'fsu', 'dekorte', 'dirac', 'scri', 'Stephen', 'L', 'DeKorte', 'I', 'a', '3', 'hour', 'on', 'PBS', 'the', 'other', 'about', 'of', 'Jews', 'Appearently', 'Cursades', 'agianst', 'muslilams', 'in', 'holy', 'land', 'sparked', 'widespread', 'persecution', 'and', 'jews', 'europe', 'Among', 'supporters', 'persiecution', 'were', 'none', 'than', 'Martin', 'Luther', 'Vatican', 'Hitler', 'would', 'Luthers', 'to', 'justify', 'his', 'own', 'Heck', 'quote', 'as', 'something', 'like', 'should', 'be', 'deer', 'And', 'Catholic', 'doctrine', 'for', 'was', 'extremely', 'anti', 'Semitic', 'Are', 'you', 'so', 'that', 'your', 'justice', 'are', 'worth', 'more', 'justices', 'Simone', 'de', 'Beauvoir', 'Where', 'there', 'certainty', 'above', 'all', 'withstands', 'critique', 'Karl', 'Jaspers', 'Will', '96']", 0.32821378156262554)
("topic #0 = ['Genocide', 'history', 'war']", 0.09326213987146643)
("topic #4 = ['article', 'use', 'treatment', 'College']", -0.056760235526588446)
("topic #16 = ['Evidence', 'article', 'writes', 'saw', 'show', 'use', 'reading', 'course', 'much', 'sure', 'truth', 'truths', 'doubt']", 0.0396699464016765)
("topic #1 = ['Genocide', 'writes', 'saw', 'day', 'history', 'religious', 'Later', 'centuries', 'rises']", 0.03147596081531774)
("topic #7 = ['saw', 'day', 'Later', 'remember']", -0.028002187463312924)

Explanation for class christian
("Unknown = ['From', 'conor', 'owlnet', 'rice', 'edu', 'Conor', 'Frederick', 'Prischmann', 'Subject', 'Re', 'is', 'Caused', 'by', 'Theism', 'Organization', 'Rice', 'University', '23', 'In', 'C60A0s', 'DvI', 'mailer', 'cc', 'fsu', 'dekorte', 'dirac', 'scri', 'Stephen', 'L', 'DeKorte', 'I', 'a', '3', 'hour', 'on', 'PBS', 'the', 'other', 'about', 'of', 'Jews', 'Appearently', 'Cursades', 'agianst', 'muslilams', 'in', 'holy', 'land', 'sparked', 'widespread', 'persecution', 'and', 'jews', 'europe', 'Among', 'supporters', 'persiecution', 'were', 'none', 'than', 'Martin', 'Luther', 'Vatican', 'Hitler', 'would', 'Luthers', 'to', 'justify', 'his', 'own', 'Heck', 'quote', 'as', 'something', 'like', 'should', 'be', 'deer', 'And', 'Catholic', 'doctrine', 'for', 'was', 'extremely', 'anti', 'Semitic', 'Are', 'you', 'so', 'that', 'your', 'justice', 'are', 'worth', 'more', 'justices', 'Simone', 'de', 'Beauvoir', 'Where', 'there', 'certainty', 'above', 'all', 'withstands', 'critique', 'Karl', 'Jaspers', 'Will', '96']", 0.19007203657283797)
("topic #0 = ['Genocide', 'history', 'war']", -0.08091996472474033)
("topic #16 = ['Evidence', 'article', 'writes', 'saw', 'show', 'use', 'reading', 'course', 'much', 'sure', 'truth', 'truths', 'doubt']", 0.0795783897530914)
("topic #1 = ['Genocide', 'writes', 'saw', 'day', 'history', 'religious', 'Later', 'centuries', 'rises']", -0.06146167143991215)
("topic #11 = ['Lines', 'writes', 'saw', 'use', 'writings', 'reading', 'sure']", 0.044624102957304054)
("topic #4 = ['article', 'use', 'treatment', 'College']", 0.03583792222797255)

Generating a topic map based on another text corpus

A useful alternative to using an LDA on the same dataset might be training an LDA on another Dataset.

Since we want to represent as many words as possible in our LDA, a useful dataset needs to be composed of a huge number of words. Therefore, I will now demonstrate how the complete Wikipedia can be used to create topics and explain a classifier classificaion.

Load a previously trained LDA, that was created using the complete wikipedia. An detailed instruction on how to create an LDA using the wikipedia dump can be found here.

Again, a previously created LDA is loaded

In [14]:
from gensim.test.utils import datapath
from gensim.models import LdaModel

# Save model to disk.
temp_file_wiki = datapath("secretLocatio/src/examples//models/wiki/lda_wiki_100topics.model")
# Load a potentially pretrained model from disk.
lda_model_wiki = LdaModel.load(temp_file_wiki)

topics_wiki = []
for x in range(lda_model_wiki.num_topics):
    topics_wiki.append('topic #'+str(x))

To use this LDA we have to rewrite our word to topic mapping. Again this is done in the same was as before and we keep the same minimal probabilty. Howerver, since we load an LDA moder we also need the dictornary that was used to create the model.

In [15]:
# load id->word mapping (the dictionary used to build LDA model )
dictionary = gensim.corpora.Dictionary.load_from_text('secretLocation/src/examples//models/wiki/_wordids.txt.bz2')

minimum_probability_wiki = 0.0004

def word_to_topics_wiki(word):
    """
    Maps a word on its corresponding topics
    :param word: the word that is searched for
    :return: list of topics
    """

    # watch out to use same lemmatization as used in LDA
    
    word_lem_list = data_processor.lemmatize(word)
    if len(word_lem_list)>0:
        word_lem = word_lem_list[0]
        if  word_lem in dictionary.token2id:
            word_id = dictionary.token2id[word_lem]
            return [x[0] for x in lda_model_wiki.get_term_topics(word_id,minimum_probability=minimum_probability_wiki)]
        else:
            return []
    else:
        return []

create an explanasion

In [16]:
explainer_wiki = LimeTextByTopicsExplainer(class_names=class_names, word_to_topics=word_to_topics_wiki, topics=topics_wiki)
exp_wiki = explainer_wiki.explain_instance(newsgroups_test.data[idx], c.predict_proba, num_features=6, top_labels=2)
print('Document id: %d' % idx)
print('Predicted class =', class_names[nb.predict(test_vectors[idx]).reshape(1,-1)[0,0]])
print('True class: %s\n' % class_names[newsgroups_test.target[idx]])

for x in exp_wiki.available_labels():
    print ('Explanation for class %s' % class_names[x])
    print ('\n'.join(map(str, exp_wiki.as_list(label=x))))
    print ()

exp_wiki.show_in_notebook(text=False, labels=exp_wiki.available_labels())
Document id: 1340
Predicted class = atheism
True class: atheism

Explanation for class atheism
("topic #1 = ['Caused']", 0.09942436118212541)
("topic #9 = ['Caused', 'Evidence', 'Lines', 'much']", 0.0992796020680122)
("topic #13 = ['Caused', 'Evidence', 'treatment', 'anti']", 0.08980769007037705)
("topic #84 = ['Rice', 'none']", 0.03486382235830195)
("topic #80 = ['Rice']", 0.03294360078405544)
("topic #39 = ['Rice', 'Lines']", 0.03219152498405598)

Explanation for class christian
("topic #1 = ['Caused']", -0.10257225113973183)
("topic #9 = ['Caused', 'Evidence', 'Lines', 'much']", -0.10100208900307027)
("topic #13 = ['Caused', 'Evidence', 'treatment', 'anti']", -0.09022083747942802)
("topic #46 = ['Evidence', 'course', 'much', 'rises']", -0.039057400409179675)
("topic #43 = ['religious', 'Semitic']", 0.03477762688133355)
("topic #12 = ['Luthers']", 0.03175658901352929)

or create an explanation with all words that could not have been mapped

In [17]:
explainer_wiki_all = LimeTextByTopicsExplainer(class_names=class_names, word_to_topics=word_to_topics_wiki, topics=topics_wiki, consider_all_words=True)
exp_wiki_all = explainer_wiki_all.explain_instance(newsgroups_test.data[idx], c.predict_proba, num_features=6, top_labels=2)
print('Document id: %d' % idx)
print('Predicted class =', class_names[nb.predict(test_vectors[idx]).reshape(1,-1)[0,0]])
print('True class: %s\n' % class_names[newsgroups_test.target[idx]])

for x in exp_wiki_all.available_labels():
    print ('Explanation for class %s' % class_names[x])
    print ('\n'.join(map(str, exp_wiki_all.as_list(label=x))))
    print ()

exp_wiki_all.show_in_notebook(text=False, labels=exp_wiki.available_labels())
Document id: 1340
Predicted class = atheism
True class: atheism

Explanation for class atheism
("topic #1 = ['Caused']", 0.14314525892803848)
("topic #9 = ['Caused', 'Evidence', 'Lines', 'much']", 0.13857059191997537)
("topic #13 = ['Caused', 'Evidence', 'treatment', 'anti']", 0.13439193395741486)
("topic #42 = ['Rice', 'show']", 0.05247832296495967)
("topic #39 = ['Rice', 'Lines']", 0.0482852231464416)
("topic #43 = ['religious', 'Semitic']", -0.04418537698347023)

Explanation for class christian
("Unknown = ['From', 'conor', 'owlnet', 'rice', 'edu', 'Conor', 'Frederick', 'Prischmann', 'Re', 'Genocide', 'is', 'by', 'Theism', 'Organization', 'University', '23', 'In', 'C60A0s', 'DvI', 'mailer', 'cc', 'fsu', 'dekorte', 'dirac', 'scri', 'Stephen', 'L', 'DeKorte', 'I', 'saw', 'a', '3', 'on', 'PBS', 'the', 'other', 'day', 'about', 'history', 'of', 'Jews', 'Appearently', 'Cursades', 'war', 'agianst', 'muslilams', 'in', 'holy', 'land', 'and', 'jews', 'europe', 'Among', 'persiecution', 'were', 'than', 'Martin', 'Luther', 'Vatican', 'Later', 'Hitler', 'would', 'use', 'to', 'justify', 'his', 'own', 'Heck', 'quote', 'as', 'something', 'like', 'should', 'be', 'And', 'Catholic', 'doctrine', 'for', 'centuries', 'was', 'extremely', 'Are', 'you', 'so', 'that', 'your', 'justice', 'are', 'worth', 'more', 'de', 'Beauvoir', 'Where', 'there', 'certainty', 'above', 'all', 'withstands', 'critique', 'Karl', 'Will', 'College', '96']", 0.3928051119033661)
("topic #13 = ['Caused', 'Evidence', 'treatment', 'anti']", -0.10458193110351266)
("topic #9 = ['Caused', 'Evidence', 'Lines', 'much']", -0.10269583747210081)
("topic #1 = ['Caused']", -0.10059217178085612)
("topic #12 = ['Luthers']", 0.06530847002257624)
("topic #43 = ['religious', 'Semitic']", 0.06441894584934803)